#!/usr/bin/env python3

# Copyright 2018 The Imaging Source Europe GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


# This script is intended to help maintainers issue a new release.
# It does this by performing the following steps:
#
# - Checking prerequisites like the existence of a changelog
# - Informing about the current project state and the steps that will be taken
# - Performing all steps required. e.g. increasing version numbers.
# - Commiting these changes
# - Tagging the version in git

import sys
import argparse
from enum import Enum
from git import Repo
from subprocess import call, run
import subprocess
import re
from datetime import date


def string_replace(filename, old_string, new_string):
    # Safely read the input filename using 'with'
    with open(filename) as f:
        s = f.read()
        if old_string not in s:
            print("\\\"{old_string}\\\" not found in {filename}.".format(**locals()))
            return

    # Safely write the changed content, if found in the file
    with open(filename, 'w') as f:
        print("Changing \"{old_string}\" to \"{new_string}\" in {filename}".format(**locals()))
        s = s.replace(old_string, new_string)
        f.write(s)


def regex_replace(filename, pattern, replace):
    """
    "filename" relative file path to file that shall be worked with
    "pattern" regex string that shall be replaced
    "replace" replacement string
    """

    # open the source file and read it
    with open(filename, 'r') as fh:
        subject = fh.read()

    # create the pattern object. Note the "r".
    # In case you're unfamiliar with Python this is to set the string
    # as raw so we don't have to escape our escape characters
    pattern = re.compile(r'%s' % pattern)
    # do the replace
    result = pattern.sub(replace, subject)

    if result == subject:
        print("Could not find pattern in file.")
        return False

    # write the file
    with open(filename, 'w') as f_out:
        f_out.write(result)
    return True


def query_yes_no(question, default: str="yes", assume_yes: bool=False):
    """Ask a yes/no question via raw_input() and return their answer.

    "question" is a string that is presented to the user.
    "default" is the presumed answer if the user just hits <Enter>.
        It must be "yes" (the default), "no" or None (meaning
        an answer is required of the user).
    "assume_yes" is a toggle to make the function assume the user answers
        with the wished default
    The "answer" return value is True for "yes" or False for "no".
    """

    valid = {"yes": True, "y": True, "ye": True,
             "no": False, "n": False}
    if default is None:
        prompt = " [y/n] "
    elif default == "yes":
        prompt = " [Y/n] "
    elif default == "no":
        prompt = " [y/N] "
    else:
        raise ValueError("invalid default answer: '%s'" % default)

    while True:
        if not assume_yes:
            sys.stdout.write(question + prompt)
            choice = input().lower()
        else:
            print(question + prompt + default)
            choice = default
        if default is not None and choice == '':
            return valid[default]
        elif choice in valid:
            return valid[choice]
        else:
            sys.stdout.write("Please respond with 'yes' or 'no' "
                             "(or 'y' or 'n').\n")


class ReleaseType(Enum):
    """

    """
    NONE = 0
    MAJOR = 1
    MINOR = 2
    PATCH = 3


def determine_release_type(is_major: bool,
                           is_minor: bool,
                           is_patch: bool):
    """
    Use input flags to determine ReleaseType
    """
    def TrueXor(*args):
        """
        Take x arguments, sum them up and compare.
        Returns True when only one flag is active
        """
        return sum(args) == 1 or sum(args) == 0

    if not TrueXor(is_major, is_minor, is_patch):
        print("Unsure what release type is requested. Aborting")
        sys.exit(1)

    if is_major:
        return ReleaseType.MAJOR
    elif is_minor:
        return ReleaseType.MINOR
    elif is_patch:
        return ReleaseType.PATCH
    else:
        return ReleaseType.NONE


class Version():
    """
    Helper class for version handling
    """
    def __init__(self, major: int, minor: int, patch: int, rest: str):

        self.major = major
        self.minor = minor
        self.patch = patch
        self.rest = rest

    def __lt__(self, other):
        """
        Comparison operator
        Returns True when self is smaller than other
        """
        try:
            if self.major < other.major:
                return True
            if self.minor < other.minor and self.major == other.major:
                return True
            if (self.patch < other.patch and
                    self.minor == other.minor and
                    self.major == other.major):
                return True

            # TODO: how to handle self.rest
        except AttributeError:
            return NotImplementedError

        return False

    def __repr__(self):
        return self.__as_string(self.major, self.minor, self.patch, self.rest)

    def __as_string(self, major: int, minor: int, patch: int, rest):
        """
        Convert given arguments to a semver string
        """
        if not rest:
            rest = ""
        return "{}.{}.{}{}".format(major,
                                   minor,
                                   patch,
                                   rest)

    def current(self):
        """Return current version as string"""
        return self.__as_string(self.major,
                                self.minor,
                                self.patch,
                                self.rest)

    def bump(self, rel_type: ReleaseType):
        """
        Returns a string containing the version of the next release
        according to the given ReleaseType
        "rel_type" ReleaseType that is wanted for the next version
        """
        major = self.major
        minor = self.minor
        patch = self.patch
        rest = self.rest
        if rel_type == ReleaseType.MAJOR:
            major += 1
            minor = 0
            patch = 0
        elif rel_type == ReleaseType.MINOR:
            minor += 1
            patch = 0
        elif rel_type == ReleaseType.PATCH:
            patch += 1
        else:
            print("Custom release appendices are currently not implemented.")

        return Version(major,
                       minor,
                       patch,
                       rest)

    @staticmethod
    def from_string(version_string: str):
        """
        Static conversion method that extracts the version parts
        from the given string and returns a Version class instance
        "version_string" string that shall be disected for version information
        """
        part_list = version_string.split(".")
        if len(part_list) != 3:
            raise
        major = part_list[0]
        minor = part_list[1]
        patch = part_list[2].split("~")[0]
        if len(part_list[2].split("~")) == 2:
            rest = part_list[2].split("~")[1]
        else:
            rest = None

        return Version(int(major), int(minor), int(patch), rest)


class Release():
    """

    """

    def __init__(self, rel_type: ReleaseType,
                 dry_run: bool,
                 force: bool,
                 assume_yes: bool,
                 tag: str=None):
        self.planned_release = rel_type

        if self.planned_release == ReleaseType.NONE:
            print("Assuming patch level release is wished.")
            self.planned_release = ReleaseType.PATCH
        self.future_tag = tag
        self.future_version = None
        self.last_tag = None
        self.last_version = None
        self.dry_run = dry_run
        self.assume_yes = assume_yes
        self.force = force
        self.repo = Repo(".")
        assert not self.repo.bare

        self.find_last_tag()
        if not self.future_tag:
            self.create_future_tag()

    def find_last_tag(self):
        """
        Find the last release tag
        """

        tags = self.repo.tags

        for tag in tags:
            # print(tag)

            if "v-tiscamera-" in str(tag):
                n = re.findall('\d+', str(tag))

                if len(n) != 3:
                    # print("Could not interpret tag '{}'. The version numbers do not seem to be in major.minor.patch format.".format(tag))
                    continue

                vers = Version(int(n[0]), int(n[1]), int(n[2]), "")

                if not self.last_version or vers > self.last_version:
                    self.last_tag = str(tag)
                    self.last_version = vers

                # TODO extract rest string

        if not self.last_tag:
            print("ERROR! Unable to determine last tag")

    def create_future_version(self):
        """
        Bump the version and create a new version object
        """

        if self.last_version is str:
            return Version.from_string(self.last_version)
        return self.last_version

    def create_future_tag(self):
        """
        Create the tag that shall be applied
        """
        version = self.create_future_version()
        self.future_version = version.bump(self.planned_release)
        self.future_tag = "v-tiscamera-{}".format(self.future_version)

        if not self.future_tag:
            print("ERROR! Unable to determine possible future tag.")
            print("You can work around this with --tag <tag>")

    def has_file_been_touched(self, filename):
        """
        filename has to be relative to repository root
        """
        diff = self.repo.git.diff(self.last_tag, name_only=True)
        if filename in diff:
            return True
        return False

    def is_changelog_prequisite_good(self):

        filename = "CHANGELOG.md"
        if not self.has_file_been_touched(filename):
            print("\t No changes to the file have been commited")
            return False

        # TODO: find non regex way that is more reliable

        # This regex matches for an Unreleased section and tries
        # to determine if non whitespace text exists between that header
        # and the one of the previous release
        regex_str = r"## \[Unreleased\].*\S.*## \[{}".format(self.last_version)
        res = None
        with open(filename, 'r') as myfile:
            res = re.findall(regex_str, myfile.read(), re.MULTILINE | re.DOTALL)

        if len(res) == 1:
            return True
        print("\tThe Unreleased section does not seem to contain text")
        return False

    def get_commit_count(self):

        return len(list(self.repo.iter_commits("{}..HEAD".format(self.last_tag))))

    def print_current_state(self):
        """
        Print an overview of where we are in the repository
        """
        print("")
        print("Current state:")
        print("")
        print("\tBranch: {}".format(self.repo.active_branch.name))
        commit = self.repo.head.commit
        short_sha = self.repo.git.rev_parse(commit.hexsha, short=8)

        print("\tCommit: {} - {}: {}: {}".format(short_sha,
                                                 commit.author,
                                                 commit.committed_datetime,
                                                 commit.summary))
        print("\tLast tag: {}".format(self.last_tag))
        print("\tCommits since then: {}".format(self.get_commit_count()))

    def print_planned_actions(self):

        print("")
        print("Planned steps:")
        print("")
        print("\tTag that will be created: {}".format(self.future_tag))
        print("")
        print("\tThe following changes will be commited to the repository:")
        print("")
        print("\t\tCHANGELOG.md Version header will be added")
        print("\t\tversion.cmake Version file will be updated")
        print("\t\t")
        print("You will be asked to review the changes before they are commited.")
        print("")

    def print_checklist(self):
        """
        Print and check steps that have to be taken before a release is possible
        returns true if all steps have been taken
        """
        def step_taken(step_desc: str, taken: bool):
            taken_str = "[ ]"
            if taken:
                taken_str = "[X]"
                print("-{} {}".format(taken_str, step_desc))
            return taken

        requirements_good = True

        print("The following steps have to be taken by you before releasing:")
        print("")
        requirements_good = step_taken("Updated changelog",
                                       self.is_changelog_prequisite_good())

        return requirements_good

    def print_summary(self):
        """
        Final overview over what happened and
        what the maintainer should do next
        """

        print("Created commit for version bump")
        print("Created tag for version bump release")

        if not self.dry_run:
            print("")
            print("Your next steps probably include:")
            print("")
            print("\tgit push origin {}".format(self.future_tag))
            print("")

    def commit_changes(self, commit_message: str=None):
        """
        Commits changes made for this release to the repository
        """
        index = self.repo.index

        if len(index.diff("HEAD")) != 0:
            print("There are staged files. Cannot commit changes.")

            return False

        def stage_files():
            """
            Stage files that shall be commited
            """
            index.add(["CHANGELOG.md"])
            index.add(["version.cmake"])

        def unstage_files():
            """
            Undo changes that should be commited.
            Used for dry-runs
            """

            index.reset(["CHANGELOG.md"], force=True)
            index.reset(["version.cmake"], force=True)

            index.checkout(["CHANGELOG.md"], force=True)
            index.checkout(["version.cmake"], force=True)

        stage_files()

        print("")
        print("The following changes will be commited:")

        # diff staged files through external means
        # gitpython cannot do that for some reason

        cmd = ["git", "diff", "--cached"]

        subprocess.run(cmd, stderr=sys.stderr, stdout=sys.stdout)

        print("")
        if not query_yes_no("Are you ok with this?"):
            print("")
            print("Changes were not accepted. Aborting release. Changes remain staged.")
            # unstage_files()
            return False

        # commit
        if not commit_message:
            commit_message = "Changing version to {}".format(self.future_version)

        print("")
        print("Commiting changes to repository...", end='')

        if not self.dry_run:
            index.commit(commit_message)
        else:
            unstage_files()

        print("Done")
        return True

    def create_tag(self, tag: str):

        print("Creating tag: '{}' ...".format(tag), end='')
        if not self.dry_run:
            self.repo.create_tag(self.future_tag)
            print("Done")

    def update_files(self):
        """
        Update all files
        """

        changelog_str = "## [Unreleased]"
        changelog_repl = "## [Unreleased]\n\n## [{}] - {}".format(self.future_version,
                                                                  date.today())

        if self.dry_run:
            print("Changing in CHANGELOG.md:")
            print("\n" + changelog_str)
            print("to:")
            print("\n" + changelog_repl)
        else:
            string_replace("CHANGELOG.md",
                           changelog_str,
                           changelog_repl)

        version_str = "set\(TCAM_VERSION_MAJOR \d+\)\s*set\(TCAM_VERSION_MINOR \d+\)\s*set\(TCAM_VERSION_PATCH \d+\)"
        version_repl = ("set(TCAM_VERSION_MAJOR {})\n"
                        "set(TCAM_VERSION_MINOR {})\n"
                        "set(TCAM_VERSION_PATCH {})").format(self.future_version.major,
                                                             self.future_version.minor,
                                                             self.future_version.patch)

        if self.dry_run:
            print("\nChanging version.cmake")
            print("\n")
        else:
            regex_replace("version.cmake",
                          version_str,
                          version_repl)

    def execute_release(self):
        """"""
        self.update_files()
        if self.commit_changes():
            self.create_tag(self.future_tag)

    def run(self):

        if self.dry_run:
            print("\n!!!THIS IS A DRY RUN! NO ACTUAL STEPS WILL BE TAKEN! !!!\n")

        requirements = self.print_checklist()
        self.print_current_state()

        self.print_planned_actions()

        if not requirements:
            print("!!! The requirements for a release are not fullfilled. !!!")
            print("")

            if self.force:
                print("\tUser forced continuation. Ignoring warnings.")
            else:
                print("\tAborting.")
                return 3

        print("")
        execute_release = query_yes_no("Do you want to proceed?", "yes",
                                       self.assume_yes)

        print("")
        if execute_release:
            self.execute_release()
            self.print_summary()
        else:
            print("Aborting release.")
            return 0


def main():

    parser = argparse.ArgumentParser(description="Helps create a release by checking prerequisites and performing steps like version number bumps or git tags.")
    parser.add_argument("--major", help="Create a major release",
                        action="store_true")
    parser.add_argument("--feature", help="Create a feature release",
                        action="store_true")
    parser.add_argument("--patch", help="Create a patch release",
                        action="store_true")
    parser.add_argument("--dry-run", help="Simulate a run but don't touch the repo",
                        action="store_true")
    parser.add_argument("--force", help="Ignore security checks",
                        action="store_true")
    parser.add_argument("--yes", help="Assume user answer is always yes",
                        action="store_true")
    parser.add_argument("--tag", help="Manually set the tag that shall be used",
                        action="store")
    parser.add_argument("--commit-msg", help="Manually set the commit message that shall be used",
                        action="store")

    arguments = parser.parse_args()

    rel_type = determine_release_type(arguments.major,
                                      arguments.feature,
                                      arguments.patch)

    rel = Release(rel_type,
                  arguments.dry_run,
                  arguments.force,
                  arguments.yes,
                  arguments.tag)

    return rel.run()


if __name__ == "__main__":
    sys.exit(main())
